package server

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"mime"
	"net/http"
	"strconv"
	"sync"
	"time"

	builderApiBellatrix "github.com/attestantio/go-builder-client/api/bellatrix"
	builderApiCapella "github.com/attestantio/go-builder-client/api/capella"
	builderApiDeneb "github.com/attestantio/go-builder-client/api/deneb"
	builderApiElectra "github.com/attestantio/go-builder-client/api/electra"
	builderSpec "github.com/attestantio/go-builder-client/spec"
	"github.com/attestantio/go-eth2-client/spec"
	"github.com/attestantio/go-eth2-client/spec/phase0"
	"github.com/flashbots/mev-boost/config"
	"github.com/flashbots/mev-boost/server/params"
	"github.com/flashbots/mev-boost/server/types"
	"github.com/google/uuid"
	"github.com/sirupsen/logrus"
)

type relayBid struct {
	bid         *builderSpec.VersionedSignedBuilderBid
	relay       types.RelayEntry
	contentType string
}

type bidResult struct {
	bid         *builderSpec.VersionedSignedBuilderBid
	contentType string
	timestamp   time.Time
}

// getHeader requests a bid from each relay and returns the most profitable one
func (m *BoostService) getHeader(log *logrus.Entry, slot phase0.Slot, pubkey, parentHashHex string, ua UserAgent, proposerAcceptContentTypes string, userTimeout uint64) (bidResp, error) {
	// Ensure arguments are valid
	if len(pubkey) != 98 {
		return bidResp{}, errInvalidPubkey
	}
	if len(parentHashHex) != 66 {
		return bidResp{}, errInvalidHash
	}

	// Make sure we have a uid for this slot
	m.slotUIDLock.Lock()
	if m.slotUID.slot < slot {
		m.slotUID.slot = slot
		m.slotUID.uid = uuid.New()
	}
	slotUID := m.slotUID.uid
	m.slotUIDLock.Unlock()
	log = log.WithField("slotUID", slotUID)

	// Compute these once, instead of for each relay
	userAgent := wrapUserAgent(ua)

	// Log how late into the slot the request starts
	slotStartTimestamp := m.genesisTime + uint64(slot)*config.SlotTimeSec
	msIntoSlot := uint64(time.Now().UTC().UnixMilli()) - slotStartTimestamp*1000
	log.WithFields(logrus.Fields{
		"genesisTime": m.genesisTime,
		"slotTimeSec": config.SlotTimeSec,
		"msIntoSlot":  msIntoSlot,
	}).Infof("getHeader request start - %d milliseconds into slot %d", msIntoSlot, slot)

	var (
		mu           sync.Mutex
		wg           sync.WaitGroup
		relayBids    []relayBid
		maxTimeoutMs uint64
	)

	m.relayConfigsLock.RLock()
	relayConfigs := m.relayConfigs
	timeoutGetHeaderMs := m.timeoutGetHeaderMs
	lateInSlotTimeMs := m.lateInSlotTimeMs
	m.relayConfigsLock.RUnlock()

	// check to see if we are already past the late-in-slot deadline
	if msIntoSlot >= lateInSlotTimeMs {
		log.WithFields(logrus.Fields{
			"msIntoSlot":       msIntoSlot,
			"lateInSlotTimeMs": lateInSlotTimeMs,
		}).Warn("getHeader request skipped because we are already past the lateInSlotTimeMs deadline")
		return bidResp{}, nil
	}

	// calc the maximum timeout budget for getHeader requests.
	// the budget is constrained by two factors:
	// 1. timeoutGetHeaderMs: configured maximum timeout for getHeader requests
	// 2. lateInSlotTimeMs - msIntoSlot: remaining time before we hit the late in slot deadline
	// we use the minimum of these two values to ensure we:
	// - don't exceed the configured timeout limit
	// - don't continue requesting headers after the late-in-slot deadline
	// example: if timeoutGetHeaderMs=950ms, lateInSlotTimeMs=2000ms, and msIntoSlot=500ms:
	//   - remaining slot time = 2000 - 500 = 1500ms
	//   - maxTimeoutMs = min(950, 1500) = 950ms
	// example: if timeoutGetHeaderMs=2000ms, lateInSlotTimeMs=2000ms, and msIntoSlot=1500ms:
	//   - remaining slot time = 2000 - 1500 = 500ms
	//   - maxTimeoutMs = min(2000, 500) = 500ms
	if timeoutGetHeaderMs < lateInSlotTimeMs-msIntoSlot {
		maxTimeoutMs = timeoutGetHeaderMs
	} else {
		maxTimeoutMs = lateInSlotTimeMs - msIntoSlot
	}

	if maxTimeoutMs == 0 {
		return bidResp{}, nil
	}

	if userTimeout > 0 {
		if userTimeout < maxTimeoutMs {
			maxTimeoutMs = userTimeout
		}
	}

	// Request a bid from each relay
	for _, relayConfig := range relayConfigs {
		wg.Add(1)
		go func(relayConfig types.RelayConfig) {
			relay := relayConfig.RelayEntry
			defer wg.Done()

			// Build the request URL
			url := relay.GetURI(fmt.Sprintf("/eth/v1/builder/header/%d/%s/%s", slot, parentHashHex, pubkey))
			log := log.WithField("url", url)

			var bid *builderSpec.VersionedSignedBuilderBid
			var contentType string

			// check if timing games enabled
			if relayConfig.EnableTimingGames {
				bid, contentType = m.handleTimingGamesGetHeader(log, relayConfig, url, slotUID, userAgent, proposerAcceptContentTypes, msIntoSlot, maxTimeoutMs)
			} else {
				bid, contentType = m.sendGetHeaderRequest(log, relay, url, slotUID, userAgent, proposerAcceptContentTypes, maxTimeoutMs)
			}

			if bid != nil {
				bidInfo, err := parseBidInfo(bid)
				if err == nil {
					valueEth := weiBigIntToEthBigFloat(bidInfo.value.ToBig())
					valueEthFloat64, _ := valueEth.Float64()
					RecordBidValue(relay.URL.Hostname(), valueEthFloat64)
				}

				mu.Lock()
				relayBids = append(relayBids, relayBid{bid: bid, relay: relay, contentType: contentType})
				mu.Unlock()
			}
		}(relayConfig)
	}
	wg.Wait()

	var (
		result = bidResp{}
		relays = make(map[BlockHashHex][]types.RelayEntry)
	)

	// process the bids and select the one with the best value
	for _, rb := range relayBids {
		m.processBid(log, rb.relay, rb.bid, rb.contentType, parentHashHex, &result, relays, slot)
	}

	// Set the winning relays before returning
	result.relays = relays[BlockHashHex(result.bidInfo.blockHash.String())]

	if len(result.relays) > 0 {
		RecordWinningBidValue(result.bidInfo.value.Float64())
	}

	return result, nil
}

// handleTimingGamesGetHeader implements timing games strategy for a relay
// Returns the latest bid and its content type from multiple timed requests
func (m *BoostService) handleTimingGamesGetHeader(
	log *logrus.Entry,
	relayConfig types.RelayConfig,
	url string,
	slotUID uuid.UUID,
	userAgent string,
	proposerAcceptContentTypes string,
	msIntoSlot uint64,
	timeoutLeftMs uint64,
) (*builderSpec.VersionedSignedBuilderBid, string) {
	relay := relayConfig.RelayEntry

	// wait til target time is configured
	if relayConfig.TargetFirstRequestMs > 0 {
		delayMs := relayConfig.TargetFirstRequestMs - msIntoSlot
		if delayMs > 0 {
			log.WithFields(logrus.Fields{
				"targetMs":   relayConfig.TargetFirstRequestMs,
				"msIntoSlot": msIntoSlot,
				"delayMs":    delayMs,
			}).Debug("waiting to send header request via timing games")
			timeoutLeftMs -= delayMs
			time.Sleep(time.Duration(delayMs) * time.Millisecond)
		}
	}

	if relayConfig.FrequencyGetHeaderMs == 0 {
		// in the case if frequency is not set, send only one getHeader request
		return m.sendGetHeaderRequest(log, relay, url, slotUID, userAgent, proposerAcceptContentTypes, timeoutLeftMs)
	}

	// send multiple requests at frequency intervals
	log.WithFields(logrus.Fields{
		"frequencyMs":   relayConfig.FrequencyGetHeaderMs,
		"timeoutLeftMs": timeoutLeftMs,
	}).Debug("sending multiple header requests via timing games")

	var (
		bidResults []bidResult
		mu         sync.Mutex
		wg         sync.WaitGroup
	)

	// helper to send a request with the remaining timeout budget
	sendTimedRequest := func(timeoutMs uint64) {
		defer wg.Done()
		bid, contentType := m.sendGetHeaderRequest(log, relay, url, slotUID, userAgent, proposerAcceptContentTypes, timeoutMs)
		if bid != nil {
			mu.Lock()
			bidResults = append(bidResults, bidResult{
				bid:         bid,
				contentType: contentType,
				timestamp:   time.Now(),
			})
			mu.Unlock()
		}
	}

	// send first request asap
	wg.Add(1)
	go sendTimedRequest(timeoutLeftMs)

	ticker := time.NewTicker(time.Duration(relayConfig.FrequencyGetHeaderMs) * time.Millisecond)
	defer ticker.Stop()
	timeoutCh := time.After(time.Duration(timeoutLeftMs) * time.Millisecond)

	// send subsequent requests at regular intervals until timeout
loop:
	for {
		select {
		case <-ticker.C:
			// dec the remaining timeout budget and send request
			if timeoutLeftMs > relayConfig.FrequencyGetHeaderMs {
				// decreasing the remaining timeout budget, so subsequent get_header
				// requests get less and less time from the overall budget
				timeoutLeftMs -= relayConfig.FrequencyGetHeaderMs
				wg.Add(1)
				go sendTimedRequest(timeoutLeftMs)
			}
		case <-timeoutCh:
			break loop
		}
	}

	wg.Wait()

	// if no bids were received return an empty bid
	if len(bidResults) == 0 {
		log.Warn("no headers received via timing games")
		return nil, ""
	}

	var (
		latestBid         *builderSpec.VersionedSignedBuilderBid
		latestContentType string
		latestTime        time.Time
	)
	// select only the bid which was most recently received and return it
	log.WithField("totalBids", len(bidResults)).Debug("received headers from relay via timing games")

	for _, br := range bidResults {
		if latestBid == nil || br.timestamp.After(latestTime) {
			latestBid = br.bid
			latestContentType = br.contentType
			latestTime = br.timestamp
		}
	}
	return latestBid, latestContentType
}

// sendGetHeaderRequest sends a single getHeader request to a relay and returns the bid and content type
func (m *BoostService) sendGetHeaderRequest(
	log *logrus.Entry,
	relay types.RelayEntry,
	url string,
	slotUID uuid.UUID,
	userAgent string,
	proposerAcceptContentTypes string,
	timeoutMs uint64,
) (*builderSpec.VersionedSignedBuilderBid, string) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond)
	defer cancel()

	// Make a new request
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		log.WithError(err).Warn("error creating new request")
		return nil, ""
	}

	// Add header fields to this request
	req.Header.Set(HeaderAccept, proposerAcceptContentTypes)
	req.Header.Set(HeaderKeySlotUID, slotUID.String())
	req.Header.Set(HeaderUserAgent, userAgent)
	req.Header.Set(HeaderDateMilliseconds, fmt.Sprintf("%d", time.Now().UTC().UnixMilli()))
	req.Header.Set(HeaderTimeoutMs, strconv.FormatUint(timeoutMs, 10))

	// Send the request
	log.Debug("requesting header")
	start := time.Now()

	resp, err := m.httpClientGetHeader.Do(req)
	RecordRelayLatency(params.PathGetHeader, relay.URL.Hostname(), float64(time.Since(start).Microseconds()))
	if err != nil {
		log.WithError(err).Warn("error calling getHeader on relay")
		return nil, ""
	}
	defer resp.Body.Close()

	RecordRelayStatusCode(strconv.Itoa(resp.StatusCode), params.PathGetHeader, relay.URL.Hostname())

	// Check if no header is available
	if resp.StatusCode == http.StatusNoContent {
		log.Debug("no-content response")
		return nil, ""
	}

	// Check that the response was successful
	if resp.StatusCode != http.StatusOK {
		err = fmt.Errorf("%w: %d", errHTTPErrorResponse, resp.StatusCode)
		log.WithError(err).Warn("error status code")
		return nil, ""
	}

	// Get the resp body content
	respBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		log.WithError(err).Warn("error reading response body")
		return nil, ""
	}

	// Get the response's content type, default to JSON
	respContentType, _, err := mime.ParseMediaType(resp.Header.Get(HeaderContentType))
	if err != nil {
		log.WithError(err).Warn("error parsing response content type")
		respContentType = MediaTypeJSON
	}
	log = log.WithField("respContentType", respContentType)

	// Get the optional version, used with SSZ decoding
	respEthConsensusVersion := resp.Header.Get(HeaderEthConsensusVersion)
	log = log.WithField("respEthConsensusVersion", respEthConsensusVersion)

	// Decode bid
	bid := new(builderSpec.VersionedSignedBuilderBid)
	err = decodeBid(respBytes, respContentType, respEthConsensusVersion, bid)
	if err != nil {
		log.WithError(err).Warn("error decoding bid")
		return nil, ""
	}

	// Skip if bid is empty
	if bid.IsEmpty() {
		log.Debug("skipping empty bid")
		return nil, ""
	}

	return bid, respContentType
}

// processBid validates and stores a bid if it's better than the current best
func (m *BoostService) processBid(
	log *logrus.Entry,
	relay types.RelayEntry,
	bid *builderSpec.VersionedSignedBuilderBid,
	respContentType string,
	parentHashHex string,
	result *bidResp,
	relays map[BlockHashHex][]types.RelayEntry,
	slot phase0.Slot,
) {
	// Getting the bid info will check if there are missing fields in the response
	bidInfo, err := parseBidInfo(bid)
	if err != nil {
		log.WithError(err).Warn("error parsing bid info")
		return
	}

	// Ignore bids with an empty block
	if bidInfo.blockHash == nilHash {
		log.Warn("relay responded with empty block hash")
		return
	}

	// Add some info about the bid to the logger
	valueEth := weiBigIntToEthBigFloat(bidInfo.value.ToBig())
	log = log.WithFields(logrus.Fields{
		"blockNumber": bidInfo.blockNumber,
		"blockHash":   bidInfo.blockHash.String(),
		"txRoot":      bidInfo.txRoot.String(),
		"value":       valueEth.Text('f', 18),
	})

	// Ensure the bid uses the correct public key
	if relay.PublicKey.String() != bidInfo.pubkey.String() {
		log.Errorf("bid pubkey mismatch. expected: %s - got: %s", relay.PublicKey.String(), bidInfo.pubkey.String())
		return
	}

	// Verify the relay signature in the relay response
	if !config.SkipRelaySignatureCheck {
		ok, err := checkRelaySignature(bid, m.builderSigningDomain, relay.PublicKey)
		if err != nil {
			log.WithError(err).Error("error verifying relay signature")
			return
		}
		if !ok {
			log.Error("failed to verify relay signature")
			return
		}
	}

	// Verify response coherence with proposer's input data
	if bidInfo.parentHash.String() != parentHashHex {
		log.WithFields(logrus.Fields{
			"originalParentHash": parentHashHex,
			"responseParentHash": bidInfo.parentHash.String(),
		}).Error("proposer and relay parent hashes are not the same")
		return
	}

	// Ignore bids with 0 value
	isZeroValue := bidInfo.value.IsZero()
	isEmptyListTxRoot := bidInfo.txRoot.String() == "0x7ffe241ea60187fdb0187bfa22de35d1f9bed7ab061d9401fd47e34a54fbede1"
	if isZeroValue || isEmptyListTxRoot {
		log.Warn("ignoring bid with 0 value")
		return
	}

	log.Debug("bid received")

	RecordRelayLastSlot(relay.URL.Hostname(), uint64(slot))

	valueEthFloat64, _ := valueEth.Float64()
	RecordBidValue(relay.URL.Hostname(), valueEthFloat64)

	// Skip if value is lower than the minimum bid
	if bidInfo.value.CmpBig(m.relayMinBid.BigInt()) == -1 {
		log.Debug("ignoring bid below min-bid value")
		IncrementBidBelowMinBid(relay.URL.Hostname())
		return
	}

	// Create a copy of the relay instance with its encoding preference. If we request SSZ and the relay
	// responds with JSON, we know that it does not support SSZ yet. This preference will be used in getPayload,
	// because we must encode the blinded block in the request in such a way that the relay can decode it.
	relayWithEncodingPreference := relay.Copy()
	relayWithEncodingPreference.SupportsSSZ = respContentType == MediaTypeOctetStream

	// Remember which relays delivered which bids (multiple relays might deliver the top bid)
	relays[BlockHashHex(bidInfo.blockHash.String())] = append(relays[BlockHashHex(bidInfo.blockHash.String())], relayWithEncodingPreference)

	// Compare the bid with already known top bid (if any)
	if !result.response.IsEmpty() {
		valueDiff := bidInfo.value.Cmp(result.bidInfo.value)
		switch valueDiff {
		case -1:
			// The current bid is less profitable than already known one
			log.Debug("ignoring less profitable bid")
			return
		case 0:
			// The current bid is equally profitable as already known one
			// Use hash as tiebreaker
			previousBidBlockHash := result.bidInfo.blockHash
			if bidInfo.blockHash.String() >= previousBidBlockHash.String() {
				log.Debug("equally profitable bid lost tiebreaker")
				return
			}
		}
	}

	// Use this relay's response as mev-boost response because it's most profitable
	log.Debug("new best bid")
	result.response = *bid
	result.bidInfo = bidInfo

	result.t = time.Now()
}

// decodeBid decodes a bid by SSZ or JSON, depending on the provided respContentType
func decodeBid(respBytes []byte, respContentType, ethConsensusVersion string, bid *builderSpec.VersionedSignedBuilderBid) error {
	switch respContentType {
	case MediaTypeOctetStream:
		if ethConsensusVersion != "" {
			// Do SSZ decoding
			switch ethConsensusVersion {
			case EthConsensusVersionBellatrix:
				bid.Version = spec.DataVersionBellatrix
				bid.Bellatrix = new(builderApiBellatrix.SignedBuilderBid)
				return bid.Bellatrix.UnmarshalSSZ(respBytes)
			case EthConsensusVersionCapella:
				bid.Version = spec.DataVersionCapella
				bid.Capella = new(builderApiCapella.SignedBuilderBid)
				return bid.Capella.UnmarshalSSZ(respBytes)
			case EthConsensusVersionDeneb:
				bid.Version = spec.DataVersionDeneb
				bid.Deneb = new(builderApiDeneb.SignedBuilderBid)
				return bid.Deneb.UnmarshalSSZ(respBytes)
			case EthConsensusVersionElectra:
				bid.Version = spec.DataVersionElectra
				bid.Electra = new(builderApiElectra.SignedBuilderBid)
				return bid.Electra.UnmarshalSSZ(respBytes)
			case EthConsensusVersionFulu:
				bid.Version = spec.DataVersionFulu
				bid.Fulu = new(builderApiElectra.SignedBuilderBid)
				return bid.Fulu.UnmarshalSSZ(respBytes)
			default:
				return errInvalidForkVersion
			}
		} else {
			return types.ErrMissingEthConsensusVersion
		}
	case MediaTypeJSON:
		// Do JSON decoding
		return json.Unmarshal(respBytes, bid)
	}
	return types.ErrInvalidContentType
}

// respondGetHeaderJSON responds to the proposer in JSON
func (m *BoostService) respondGetHeaderJSON(w http.ResponseWriter, result *bidResp) {
	w.Header().Set(HeaderContentType, MediaTypeJSON)
	w.WriteHeader(http.StatusOK)

	// Serialize and write the data
	if err := json.NewEncoder(w).Encode(&result.response); err != nil {
		m.log.WithField("response", result.response).WithError(err).Error("could not write OK response")
		http.Error(w, "", http.StatusInternalServerError)
	}
}

// respondGetHeaderSSZ responds to the proposer in SSZ
func (m *BoostService) respondGetHeaderSSZ(w http.ResponseWriter, result *bidResp) {
	// Serialize the response
	var err error
	var sszData []byte
	switch result.response.Version {
	case spec.DataVersionBellatrix:
		w.Header().Set(HeaderEthConsensusVersion, EthConsensusVersionBellatrix)
		sszData, err = result.response.Bellatrix.MarshalSSZ()
	case spec.DataVersionCapella:
		w.Header().Set(HeaderEthConsensusVersion, EthConsensusVersionCapella)
		sszData, err = result.response.Capella.MarshalSSZ()
	case spec.DataVersionDeneb:
		w.Header().Set(HeaderEthConsensusVersion, EthConsensusVersionDeneb)
		sszData, err = result.response.Deneb.MarshalSSZ()
	case spec.DataVersionElectra:
		w.Header().Set(HeaderEthConsensusVersion, EthConsensusVersionElectra)
		sszData, err = result.response.Electra.MarshalSSZ()
	case spec.DataVersionFulu:
		w.Header().Set(HeaderEthConsensusVersion, EthConsensusVersionFulu)
		sszData, err = result.response.Fulu.MarshalSSZ()
	case spec.DataVersionUnknown, spec.DataVersionPhase0, spec.DataVersionAltair:
		err = errInvalidForkVersion
	}
	if err != nil {
		m.log.WithError(err).Error("error serializing response as SSZ")
		http.Error(w, "failed to serialize response", http.StatusInternalServerError)
		return
	}

	// Write the header
	w.Header().Set(HeaderContentType, MediaTypeOctetStream)
	w.WriteHeader(http.StatusOK)

	// Write SSZ data
	if _, err := w.Write(sszData); err != nil {
		m.log.WithError(err).Error("error writing SSZ response")
		http.Error(w, "failed to write response", http.StatusInternalServerError)
	}
}
